All files / src/app/shared-results/[token] page.tsx

0% Statements 0/23
0% Branches 0/22
0% Functions 0/6
0% Lines 0/20

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84                                                                                                                                                                       
import { getDb } from "@/lib/db";
import { ShareController } from "@/server/api/shares";
import { ResultController } from "@/server/api/results";
import { ResultItemController } from "@/server/api/result-items";
import { ItemController } from "@/server/api/items";
import { PhotoController } from "@/server/api/photos";
import { getSharedResultPhotoAssetUrl, PHOTO_WIDTH } from "@/lib/photos";
import { Badge, Card, EmptyState } from "@/components/ui";
import { ItemCard } from "@/features/items/ItemCard";
import { notFound } from "next/navigation";
 
interface SharedResultPageProps {
  params: Promise<{ token: string }>;
}
 
export default async function SharedResultPage({ params }: SharedResultPageProps) {
  const { token } = await params;
  const db = getDb();
  const share = await ShareController.fromDb(db).findByToken(token);
 
  if (!share || share.status !== "active" || (share.expiresAt && share.expiresAt < new Date())) {
    notFound();
  }
 
  const result = await ResultController.fromDb(db).getResultOrNull(share.sortResultId);
  if (!result || result.status !== "finalized") {
    notFound();
  }
 
  const [resultItems, items] = await Promise.all([
    ResultItemController.fromDb(db).listResultItems(result.id),
    ItemController.fromDb(db).listItems(result.collectionId),
  ]);
  const itemById = new Map(items.map((item) => [item.id, item]));
  const sortedResultItems = [...resultItems].sort((a, b) => (a.manualRank ?? a.computedRank) - (b.manualRank ?? b.computedRank));
 
  // Anonymous viewer: photo bytes must come from the token-scoped route, never
  // the session-gated /api/photo-files one (purses-21x5.1) — a signed-out
  // caller would 401 on every image.
  const covers = await PhotoController.fromDb(db).findCoverPhotosByItemIds(
    sortedResultItems.map((ri) => ri.itemId),
  );
  const heroPhotoUrl = (itemId: string) => {
    const cover = covers.get(itemId);
    return cover ? getSharedResultPhotoAssetUrl(token, cover, { w: PHOTO_WIDTH.card }) : undefined;
  };
 
  return (
    <main className="min-h-screen px-4 py-6 sm:py-8">
      <div className="mx-auto max-w-6xl space-y-8">
        <div>
          <div className="mb-3">
            <Badge variant="finalized">finalized</Badge>
          </div>
          <h1 className="text-2xl text-ink sm:text-3xl">{result.name}</h1>
          <p className="mt-2 text-ink-soft">Shared read-only result</p>
        </div>
 
        <Card padding="lg">
          {sortedResultItems.length === 0 ? (
            <EmptyState heading="No ranked items" message="This shared result does not contain ranked items yet" />
          ) : (
            <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
              {sortedResultItems.map((resultItem) => {
                const item = itemById.get(resultItem.itemId);
                if (!item) return null;
                return (
                  <ItemCard
                    key={resultItem.id}
                    item={item}
                    collectionId={result.collectionId}
                    heroPhotoUrl={heroPhotoUrl(item.id)}
                    bucket={resultItem.manualBucket || resultItem.computedBucket}
                  />
                );
              })}
            </div>
          )}
        </Card>
      </div>
    </main>
  );
}